Перейти к основному содержимому

5.05. Работа с типами

Разработчику Архитектору

Работа с числами

Числа — основа вычислений. Но не все числа одинаковы. В C# есть разные типы чисел, каждый со своими особенностями точности, производительности и назначения. Выбор неправильного типа может привести к ошибкам округления, переполнению или финансовым потерям. float, double, decimal — все они дробные, но работают совершенно по-разному.

float имеет точность ~6–7 значащих цифр и подходит для графики, физики, играх — где важна скорость, а не точность. Хранится в формате IEEE 754 (binary floating-point).

double имеет точность ~15–16 значащих цифр и является стандартным выбором для научных, инженерных или математических расчётов. Тоже binary floating-point, но точнее, чем float.

И как можно понять, деньги требуют максимальной точности.

Поэтому для них используем именно decimal, с точностью 28–29 значащих цифр. Хранится decimal в десятичном представлении (base-10), что может быть критичным для бухгалтерии. decimal медленнее, чем float/double, но точнее. И именно его нужно использовать для финансов, денег, налогов, цен.

Для обычных целых чисел, индексов и счётчиков можно использовать int.

Различия типов приносят забавную магию. Например, ошибки округления и сравнение чисел - проблема 0.1 + 0.2 != 0.3:

double a = 0.1;
double b = 0.2;
Console.WriteLine(a + b == 0.3); // False!

Почему? Потому что 0.1 не может быть точно представлено в двоичной системе (как 1/3 = 0.333... в десятичной).

Чтобы такую проблему решиь, нужно сравнивать с эпсилоном:

double epsilon = 1e-10;
bool equals = Math.Abs(a + b - 0.3) < epsilon; // True

Собственно, как правильно округлять числа? C# предоставляет несколько способов округления:

МетодОписаниеПример
Math.Round(x)Округляет к ближайшему целомуMath.Round(3.7) → 4
Math.Round(x, digits)Округляет с указанным количеством знаков после запятойMath.Round(3.14159, 2) → 3.14
Math.Floor(x)Округляет вниз к наименьшему целомуMath.Floor(3.9) → 3
Math.Ceiling(x)Округляет вверх к наибольшему целомуMath.Ceiling(3.1) → 4
Math.Truncate(x)Отбрасывает дробную часть (без округления)Math.Truncate(-3.7) → -3

По умолчанию Math.Round использует «банковское округление» (то есть к чётному):

Math.Round(2.5); // → 2 (а не 3!)
Math.Round(3.5); // → 4

Почему? Чтобы избежать систематической погрешности при массовых расчётах.

Как округлять «как в школе»?

Math.Round(2.5, MidpointRounding.AwayFromZero); // → 3

Класс Math — математика в C#.

System.Math — статический класс, содержащий методы и константы для математических операций.

Основные методы класса Math:

МетодОписаниеПример
Math.Abs(x)Возвращает абсолютное значение числаMath.Abs(-5) → 5
Math.Sign(x)Возвращает знак числа: -1 (если < 0), 0 (если = 0), 1 (если > 0)Math.Sign(-10) → -1
Math.Log(x)Натуральный логарифм (по основанию e)Math.Log(Math.E) → 1
Math.Max(a, b)Возвращает большее из двух чиселMath.Max(10, 20) → 20
Math.Min(a, b)Возвращает меньшее из двух чиселMath.Min(10, 20) → 10
Math.Pow(x, y)Возводит число x в степень yMath.Pow(2, 3) → 8
Math.Sqrt(x)Возвращает квадратный корень из числаMath.Sqrt(16) → 4
Math.PIКонстанта π (приблизительно 3.14159)double circle = 2 * Math.PI * radius;
Math.Sin(x), Math.Cos(x), Math.Tan(x)Тригонометрические функции (аргумент в радианах)Math.Sin(Math.PI / 2) → 1
Math.IEEERemainder(x, y)Остаток от деления по стандарту IEEE (более точный, чем оператор %)Math.IEEERemainder(5, 3) → -1

При ошибках вычислений могут появляться специальные значения:

double.NaN, возникает при 0.0 / 0.0, Math.Sqrt(-1) и проверяется через double.IsNaN(x). double.PositiveInfinity, возникает при 1.0 / 0.0, проверяется через double.IsInfinity(x). double.NegativeInfinity, возникает при -1.0 / 0.0, проверяется через double.IsNegativeInfinity(x). NaN != NaN — даже сравнение NaN == NaN возвращает false.

А теперь подумаем о переполнениях. Представьте, что у нас есть int. Если мы применим int.MaxValue, то заполним переменную максимально возможным для типа int целым числом. А что если попытаться прибавить единицу?

int a = int.MaxValue;
a++; // → int.MinValue (переполнение, но без ошибки)

Ответ - переполнение. Но по умолчанию ошибок не возникнет.

Поэтому нужно для такого случая делать проверяемое исключение - checked - это снижает производительность, но будет выполняться проверка на переполнение:

checked
{
int a = int.MaxValue;
a++; // Исключение: OverflowException
}

И разумеется, с числами можно работать и арифметически - но думаю, плюсы минусы, умножение, деление можно не разбирать, поэтому пойдёмте дальше.

Работа со строками

В C# строки представлены типом string (алиас для System.String) — неизменяемым ссылочным типом, который требует особого подхода к манипуляциям, сравнению и производительности.

string — это последовательность символов (char), предназначенная для хранения текста. Это ссылочный тип, но ведёт себя как значимый благодаря неизменяемости и синтаксическому удобству.

Ключевая особенность string — он неизменяем (immutable). Это означает, что как только строка создана, изменить её нельзя, и любая операция, которая «изменяет» строку, например, Replace, ToUpper, конкатенация, создаёт новый объект в куче. Пример:

string s = "hello";
s = s + " world"; // На самом деле: создаётся НОВАЯ строка

В памяти создаётся строка "hello" в куче, затем создаётся строка "hello world" в куче, и переменная s теперь ссылается на новую строку. А старая строка "hello" становится "мусором" (будет удалена GC). Такая неизменяемость безопасна в многопоточности (не нужно синхронизировать), строки можно кэшировать, и всегда есть гарантия, что строка не изменится. При множественных изменениях можно заметить недостаток - низкая производительность.

Соединение строк, или конкатенация — одна из самых частых операций. Здесь она, как обычно, через +:

string result = "Hello" + " " + name + "!";

Каждый + создаёт новый объект, так что внимательнее в циклах.

Есть и аналог в методе - string.Concat, но может быть чуть эффективнее при большом количестве аргументов.

string result = string.Concat("Hello", " ", name, "!");

Когда вы много раз изменяете строку (например, в цикле), используйте System.Text.StringBuilder. Это специальный класс, который нужен для производительности, он изменяемый, хранит содержимое в буфере, который можно расширять, и избегает создания множества временных объектов. К примеру, мы в цикле медленно будем выполнять такую операцию:

string result = "";
for (int i = 0; i < 1000; i++)
{
result += i.ToString() + ", "; // 1000 объектов в куче!
}

Но можно сделать это быстро с использованием StringBuilder:

var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append(i).Append(", ");
}
string result = sb.ToString();

Основные методы StringBuilder:

  • Append(text) добавляет текст в конец;
  • AppendLine(text) добавляет текст + перевод строки;
  • Insert(index, text) вставляет текст в указанную позицию;
  • Remove(start, length) удаляет часть строки;
  • Replace(old, new) заменяет подстроку;
  • Clear() очищает содержимое;
  • ToString() возвращает итоговую строку.

Поэтому в циклах с 5+ итераций, при построении длинных строк (логи, HTML, CSV), и если заранее неизвестна длина строки, используйте StringBuilder. Если знаете примерный размер, можете указывать его в начале:

var sb = new StringBuilder(1024);

Следующая операция - интерполяция строк. Это самый удобный способ форматирования строк, и работает с любыми выражениями:

string message = $"Привет, {name}! Тебе {age} лет.";
string result = $"Результат: {a + b * 2}";
double price = 19.99;
string s = $"Цена: {price:C}"; // → "Цена: $19.99" (в зависимости от локали)
  • {x:C} - денежный формат;
  • {x:N2} - число с 2 знаками;
  • {x:F1} - фиксированное число знаков;
  • {x:D6} - целое, минимум 6 цифр;
  • {x:yyyy-MM-dd} - дата.

Интерполяция — это синтаксический сахар над string.Format, это более старый формат. Он полезен, когда нужно переиспользовать шаблон, интерполяция недоступна или строки хранятся в ресурсах (например, .resx).

string message = string.Format("Привет, {0}! Тебе {1} лет.", name, age);

String (System.String) – неизменяемый тип для работы со строками. И для работы есть основные методы:

МетодОписаниеПример
s.LengthВозвращает длину строки"hello".Length → 5
s.ToUpper()Переводит строку в верхний регистр"abc".ToUpper()"ABC"
s.ToLower()Переводит строку в нижний регистр"ABC".ToLower()"abc"
s.Contains("text")Проверяет, содержится ли подстрока в строке"hello world".Contains("world")true
s.StartsWith("pre")Проверяет, начинается ли строка с указанного текста"apple".StartsWith("app")true
s.EndsWith("end")Проверяет, заканчивается ли строка указанным текстом"apple".EndsWith("ple")true
s.IndexOf("x")Возвращает индекс первого вхождения символа или подстроки; если не найдено — -1"banana".IndexOf('a') → 1
s.LastIndexOf("x")Возвращает индекс последнего вхождения символа или подстроки"banana".LastIndexOf('a') → 5
s.Replace("old", "new")Заменяет все вхождения указанной подстроки на новую"hello world".Replace("world", "C#")"hello C#"
s.Substring(start, length)Возвращает подстроку длиной length, начиная с позиции start"hello world".Substring(6, 5)"world"
s.Split(' ')Разбивает строку на массив строк по указанному разделителю"one two three".Split(' ')["one", "two", "three"]
s.Trim()Удаляет пробелы (и другие whitespaces) в начале и конце строки" text ".Trim()"text"
TrimStart(), TrimEnd()Удаляет пробелы только в начале (TrimStart) или только в конце (TrimEnd)" text ".TrimStart()"text "
string.Format("{0} {1}", a, b)Форматирует строку, подставляя аргументы в позиции {0}, {1} и т.д.string.Format("Name: {0}, Age: {1}", name, age)
$"{name}, {age}"Интерполяция строк — позволяет вставлять выражения прямо в строкуvar s = $"Hello {name}"
string.IsNullOrEmpty(s)Возвращает true, если строка null или ""string.IsNullOrEmpty(name)
string.IsNullOrWhiteSpace(s)Возвращает true, если строка null, пустая или содержит только пробельные символыstring.IsNullOrWhiteSpace(input)

В C# сравнение строк тоже имеет особенности.

C# переопределил == для string, чтобы сравнивать содержимое, а не ссылки.

string a = "hello";
string b = "hello";
bool eq = a == b; // true (сравнение содержимого, а не ссылок!)

Для более гибкого сравнения используют .Equals():

bool eq = a.Equals(b); // по умолчанию — с учётом регистра
bool ignoreCase = a.Equals(b, StringComparison.OrdinalIgnoreCase);

string.Compare() - сравнение с указанием параметров:

int result = string.Compare(a, b, StringComparison.OrdinalIgnoreCase);
// result: 0 — равны, <0 — a < b, >0 — a > b

Соответственно, типы сравнения бывают:

  • Ordinal - быстрое, побайтовое, по умолчанию;
  • OrdinalIgnoreCase - быстрое, без учёта регистра;
  • CurrentCulture - с учётом локали (например, ё в русском языке);
  • CurrentCultureIgnoreCase - без учёта регистра, с учётом локали.

Для технических сравнений (ключи, файлы, URL) — Ordinal или OrdinalIgnoreCase. Для отображения пользователю — CurrentCulture.

Перебор строки используется через foreach char in str. Строка — это последовательность char, её можно перебирать:

string text = "C#";
foreach (char c in text)
{
Console.WriteLine(c); // C, # (как один символ)
}

char — 16-битный, поддерживает Unicode (UTF-16), но не все символы — один char (например, эмодзи — два char — surrogate pair).

Есть также такое понятие, как «сырая строка» (verbatim string), которая игноррирует escape-символы (\), разрешает переносы строк: string path = @"C:\Users\John\Documents"; Для многострочных строк добавляйте ещё кавычки (начиная с C#11):

string query = """
SELECT * FROM Users
WHERE Age > 18
ORDER BY Name
""";

Этот способ автоматически удаляет отступы, что удобно для SQL, JSON, HTML. Там можно использовать интерполяцию, если добавить $ перед кавычками:

string msg = $"""
Привет, {name}!
Ты {age} лет.
""";

Поэтому для объединения используйте интерполяцию или конкатенацию, а если их много - StringBuilder. Для форматирования, хранения шаблонов используйте string.Format или интерполяцию. Не мутируйте строки - старайтесь держаться принципов и создавайте новые.

Работа с датой и временем

В повседневной жизни мы говорим «сегодня», «в 15:30», «2025-04-05». В программировании важно понимать, что дата (Date) это только день, месяц, год, время (Time) это часы, минуты, секунды. А дата-время (DateTime) это полная метка - дата + время + (опционально) информация о часовом поясе.

В C# нет отдельных типов Date или Time. Есть:

  • DateTime — основной тип для даты и времени;
  • DateOnly и TimeOnly — появились в .NET 6;
  • DateTimeOffset — для точной привязки к UTC;
  • TimeSpan — для измерения продолжительности.

DateTime — это структура, представляющая момент времени в диапазоне от 01.01.0001 до 31.12.9999. Основные свойства:

DateTime now = DateTime.Now;

Console.WriteLine(now.Year); // 2025
Console.WriteLine(now.Month); // 4
Console.WriteLine(now.Day); // 5
Console.WriteLine(now.Hour); // 14
Console.WriteLine(now.Minute); // 30
Console.WriteLine(now.Second); // 5
Console.WriteLine(now.Millisecond); // 123

Поэтому, чтобы получить текущее время, можно использовать один из трёх вариантов:

  • DateTime.Now - текущее локальное время (с учётом часового пояса системы);
  • DateTime.UtcNow - текущее время в UTC (всемироное скоординированное время);
  • DateTime.Today - только дата (время = 00:00:00).

Рекомендуется всегда использовать UtcNow для хранения и сравнения времени в приложениях.

Важно отметить, что DateTime имеет свойство Kind, которое указывает, как интерпретировать эту дату - это тип времени, DateTime.Kind:

  • DateTimeKind.Unspecified - неизвестно (по умолчанию);
  • DateTimeKind.Local - локальное время (часовой пояс системы);
  • DateTimeKind.Utc - время в UTC.
DateTime local = DateTime.Now;           // Kind = Local
DateTime utc = DateTime.UtcNow; // Kind = Utc
DateTime unspecified = new DateTime(2025, 4, 5); // Kind = Unspecified

DateTime не хранит информацию о часовом поясе, только Kind.

Чтобы получить точное время с UTC-смещением, используется DateTimeOffset.

DateTimeOffset — это улучшенная версия DateTime, которая всегда включает смещение от UTC.

DateTimeOffset dto = new DateTimeOffset(2025, 4, 5, 14, 30, 0, TimeSpan.FromHours(+3)); // MSK

DateTimeOffset хранит точное время и смещение (например, +03:00), не зависит от локального часового пояса и идеален для хранения в БД.

DateTimeOffset now = DateTimeOffset.Now;     // Local + offset
DateTimeOffset utcNow = DateTimeOffset.UtcNow; // Utc + 0

TimeSpan представляет интервал времени: разницу между двумя моментами.

DateTime start = DateTime.Now;
// ... работа
DateTime end = DateTime.Now;

TimeSpan duration = end - start;
Console.WriteLine($"Прошло: {duration.TotalSeconds} секунд");

TimeSpan имеет свойства .Days, .Hours, .Minutes, .Seconds, .TotalDays, .TotalHours, .TotalSeconds. Вот как происходит создание:

TimeSpan interval = new TimeSpan(2, 30, 0); // 2 часа 30 минут
TimeSpan.FromHours(1.5); // 1.5 часа

TimeZoneInfo позволяет конвертировать время между часовыми поясами. К примеру, получение часового пояса:

TimeZoneInfo moscow = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time");
TimeZoneInfo paris = TimeZoneInfo.FindSystemTimeZoneById("Romance Standard Time");

Конвертация:

DateTime utcTime = DateTime.UtcNow;
DateTime moscowTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, moscow);

То есть, TimeZoneInfo стоит использовать при работе с пользователями из разных регионов.

Есть и ещё одна тема для изучения - форматирование дат. Основная задача тут это преобразование даты в строку. Для этого используют ToString("format").

Стандартные форматы:

ФорматПримерОписание
"d"05.04.2025Краткая дата
"D"Saturday, April 5, 2025Полная дата
"t"14:30Короткое время
"T"14:30:05Полное время
"f"April 5, 2025 2:30 PMПолная дата + короткое время
"F"Saturday, April 5, 2025 2:30:05 PMПолная дата и время
"g"4/5/2025 2:30 PMОбщее короткое
"G"4/5/2025 2:30:05 PMОбщее полное
"u"2025-04-05 14:30:05ZУниверсальное (UTC)
"o"2025-04-05T14:30:05.1234567+03:00Round-trip format — точное время с DateTimeOffset

Пользовательские форматы:

СпецификаторЗначениеПример
yyyyГод (4 цифры)2025
MMМесяц (01–12)04
ddДень (01–31)05
HHЧасы (00–23)14
hhЧасы (01–12)02
mmМинуты30
ssСекунды05
fffМиллисекунды123
z / zz / zzzСмещение часового пояса (+3, +03, +03:00)+3, +03:00
ttAM/PMPM
DateTime now = DateTime.Now;
string custom = now.ToString("yyyy-MM-dd HH:mm:ss"); // 2025-04-05 14:30:05

А для преобразования строки в дату, используется парсинг строк - DateTime.Parse, TryParse.

DateTime.Parse — выбрасывает исключение при ошибке:

DateTime date = DateTime.Parse("2025-04-05");

DateTime.TryParse — безопасный способ:

if (DateTime.TryParse("2025-04-05", out DateTime date))
{
Console.WriteLine(date);
}
else
{
Console.WriteLine("Неверный формат");
}

Можно выполнять с указанием стиля и культуры:

DateTime.TryParse("05/04/2025", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out date);

В .NET 6+ добавили новые типы для работы только с датой или только со временем - DateOnly и TimeOnly.

DateOnly today = DateOnly.FromDateTime(DateTime.Today);
DateOnly birthday = new(2000, 1, 1);
TimeOnly now = TimeOnly.FromDateTime(DateTime.Now);
TimeOnly lunch = new(12, 30);

Соответственно, они нужны для хранения только даты или только времени.

Итого, для работы можно использовать следующее:

МетодОписаниеПример
DateTime.NowВозвращает текущую дату и время по локальному часовому поясуvar now = DateTime.Now;
DateTime.UtcNowВозвращает текущую дату и время в формате UTCvar utc = DateTime.UtcNow;
date.AddDays(n)Возвращает новое значение DateTime, с добавленным количеством дней nvar tomorrow = DateTime.Now.AddDays(1);
date.ToString("format")Преобразует дату в строку с указанным форматомnow.ToString("yyyy-MM-dd HH:mm:ss")
DateTime.Parse(str)Преобразует строку в объект DateTime; выбрасывает исключение при ошибке парсингаvar date = DateTime.Parse("2024-04-05");
TimeSpanПредставляет интервал времени (разницу между двумя моментами)var duration = endDate - startDate;